Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 5567022348aebbe4b6a1b5d7ea8d0fb04a3cacde


Parents : 3d73872
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-22T18:10:31-05:00

feat: update discovery map with dual-halo badge icons, update default basemap to OpenStreetMap, and improve marker label visibility

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9ba18c8..5788e1b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,8 @@ All notable changes to this project will be documented in this file.
### Changed
+- Discovery map markers use dual-halo badge icons (peers, LXMF, discovered, tracking, stale) with banded cluster badges, zoom-gated labels, and cached styles for denser maps
+- Map default basemap is OpenStreetMap (OSM). Style presets list OSM first and identity config wins over stale cached tile URLs
- UI opens sooner: HTTP binds first, Reticulum starts in the background
- Conversations load faster: slim list and thread queries via fields_meta and attachment flags
- Conversation list uses a per-peer summary table so refreshes no longer scan the full message history

diff --git a/meshchatx.rsm b/meshchatx.rsm
index e7a92291..fa92840b 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index f955b0f6..f207221c 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -590,8 +590,8 @@
<div class="grid grid-cols-5 gap-1">
<button
v-for="style in [
- { id: 'openfreemap', label: 'OFM' },
{ id: 'osm', label: 'OSM' },
+ { id: 'openfreemap', label: 'OFM' },
{ id: 'carto-dark', label: 'Dark' },
{ id: 'carto-voyager', label: 'Voy' },
{ id: 'carto-light', label: 'Lite' },
@@ -1115,7 +1115,7 @@ import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import { getMdiIconPath } from "../../js/mdiIconNames.js";
-import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
+import { Style, Fill, Stroke, Circle as CircleStyle } from "ol/style";
import { shared as olIconCache } from "ol/style/IconImageCache";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
@@ -1147,13 +1147,28 @@ import {
dedupeDiscoveredMapNodes as dedupeDiscoveredMapNodesHelper,
dedupeTelemetryMarkersForMap as dedupeTelemetryMarkersForMapHelper,
} from "./internal/mapDedupe.js";
+import {
+ BADGE_SCALE_HOVER,
+ BADGE_SCALE_NORMAL,
+ DEFAULT_DISCOVERED_FACE,
+ DEFAULT_PEER_FACE,
+ DEFAULT_PEER_GLYPH,
+ getCachedClusterStyle,
+ getCachedPeerBadgeStyle,
+ shouldShowMarkerLabel,
+} from "./internal/markerStyles.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import DOMPurify from "dompurify";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
-import { detectRasterTileProviderId, nextRasterTileProviderId, TILE_PROVIDER_URLS } from "../../js/mapTileProviders.js";
+import {
+ detectRasterTileProviderId,
+ nextRasterTileProviderId,
+ TILE_PROVIDER_URLS,
+ DEFAULT_TILE_SERVER_URL,
+} from "../../js/mapTileProviders.js";
import {
fetchTileBlobWithRetry,
fetchJsonWithRetry,
@@ -1184,7 +1199,7 @@ import { styleFromMcxProperties } from "../../js/mapExchange/styleFromProperties
import { computeSegmentMetrics, buildBearingOverlayHtml, buildBearingLiveTooltipHtml } from "../../js/mapGeodesy.js";
const OPENFREEMAP_DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/bright";
-const DEFAULT_OSM_RASTER = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+const DEFAULT_OSM_RASTER = DEFAULT_TILE_SERVER_URL;
const OFFLINE_MB_TILES_URL = "/api/v1/map/tiles/{z}/{x}/{y}.png";
const OFFLINE_TRANSPARENT_TILE_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
@@ -1479,6 +1494,10 @@ export default {
if (!newVal || !oldVal || newVal.telemetry?.destination_hash !== oldVal.telemetry?.destination_hash) {
this.isMiniChatOpen = false;
}
+ // Re-evaluate zoom-gated labels when selection changes.
+ if (typeof this.markerLayer?.changed === "function") {
+ this.markerLayer.changed();
+ }
},
showSaveDrawingModal(val) {
if (val) {
@@ -1527,7 +1546,8 @@ export default {
this.currentCenter = savedState.center || [0, 0];
this.currentZoom = savedState.zoom || 2;
if (savedState.offlineEnabled !== undefined) this.offlineEnabled = savedState.offlineEnabled;
- if (savedState.tileServerUrl) this.tileServerUrl = savedState.tileServerUrl;
+ // Tile URL comes from identity config (defaults to OSM). Do not let a stale
+ // TileCache map-state override the configured basemap.
if (savedState.telemetry) this.telemetryList = savedState.telemetry;
// Temporarily store drawings to restore after map/source init
@@ -1837,13 +1857,14 @@ export default {
if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
- iconColor: isNote ? "#f59e0b" : "#3b82f6",
- bgColor: "#ffffff",
+ iconColor: DEFAULT_PEER_GLYPH,
+ bgColor: isNote ? "#d97706" : "#2563eb",
label: isNote && feature.get("note") ? "Note" : "",
isStale: false,
iconPath: isNote
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
: null,
+ showLabel: Boolean(isNote && feature.get("note")),
});
}
return new Style({
@@ -1958,26 +1979,40 @@ export default {
if (feature.get("cluster")) {
const style = this.createClusterStyle(feature.get("clusterCount") || 0, isHovered);
- style.setZIndex(isHovered ? 1000 : 200);
+ const z = isHovered ? 1000 : 200;
+ if (Array.isArray(style)) {
+ for (const s of style) s.setZIndex(z);
+ } else {
+ style.setZIndex(z);
+ }
return style;
}
- const scale = isHovered ? 0.85 : 0.6;
+ const scale = isHovered ? BADGE_SCALE_HOVER : BADGE_SCALE_NORMAL;
const zIndex = isHovered ? 1000 : 100;
-
+ const zoom = this.map?.getView?.()?.getZoom?.();
+ const selectedHash = this.selectedMarker?.telemetry?.destination_hash;
const t = feature.get("telemetry");
const peer = feature.get("peer");
const disc = feature.get("discovered");
+ const isSelected = Boolean(
+ (t && selectedHash && t.destination_hash === selectedHash) ||
+ (disc && this.selectedMarker?.discovered && this.selectedMarker.discovered === disc)
+ );
+ const showLabel = shouldShowMarkerLabel({
+ zoom,
+ hovered: isHovered,
+ selected: isSelected,
+ });
let displayName = "";
let isStale = false;
- let iconColor = "#2563eb";
- let bgColor = "#ffffff";
+ let iconColor = DEFAULT_PEER_GLYPH;
+ let bgColor = DEFAULT_PEER_FACE;
let iconPath = null;
if (t) {
displayName = peer?.display_name || t.destination_hash.substring(0, 8);
- // Calculate staleness
const now = Date.now();
const updatedAt = t.updated_at
? new Date(t.updated_at).getTime()
@@ -1995,12 +2030,13 @@ export default {
}
} else if (disc) {
displayName = disc.name;
- iconColor = "#10b981"; // emerald-500
- bgColor = "#d1fae5"; // emerald-100
+ iconColor = DEFAULT_PEER_GLYPH;
+ bgColor = DEFAULT_DISCOVERED_FACE;
iconPath = this.getMdiPath(this.getDiscoveredIconName(disc));
} else if (feature === this.queryMarker) {
displayName = "Search Result";
- iconColor = "#ef4444";
+ iconColor = DEFAULT_PEER_GLYPH;
+ bgColor = "#dc2626";
}
const style = this.createMarkerStyle({
@@ -2011,6 +2047,7 @@ export default {
iconPath,
scale,
isTracking: t ? t.is_tracking : false,
+ showLabel,
});
style.setZIndex(zIndex);
return style;
@@ -2148,11 +2185,12 @@ export default {
});
feature.setStyle(
this.createMarkerStyle({
- iconColor: "#2563eb",
- bgColor: "#bfdbfe",
+ iconColor: DEFAULT_PEER_GLYPH,
+ bgColor: "#2563eb",
label: String(label),
isStale: false,
iconPath: null,
+ showLabel: true,
})
);
this.queryMarker = feature;
@@ -4928,25 +4966,10 @@ export default {
}
},
createClusterStyle(count, isHovered) {
- const cacheKey = `cluster-${count}-${isHovered ? "h" : "n"}`;
- if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
-
- const radius = isHovered ? 20 : 18;
- const style = new Style({
- image: new CircleStyle({
- radius,
- fill: new Fill({ color: "rgba(37, 99, 235, 0.92)" }),
- stroke: new Stroke({ color: "#ffffff", width: 2 }),
- }),
- text: new Text({
- text: String(count),
- font: "bold 13px sans-serif",
- fill: new Fill({ color: "#ffffff" }),
- stroke: new Stroke({ color: "rgba(0,0,0,0.35)", width: 1 }),
- }),
+ return getCachedClusterStyle(this.styleCache, {
+ count,
+ hovered: Boolean(isHovered),
});
- this.styleCache[cacheKey] = style;
- return style;
},
buildClusterItems(feature) {
return buildClusterItemsHelper(feature);
@@ -5068,67 +5091,26 @@ export default {
}
});
},
- createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath, scale = 0.6, isTracking = false }) {
- const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}-${scale}-${isTracking}`;
- if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
-
- const markerFill = isStale ? "#d1d5db" : bgColor;
- const markerStroke = isStale ? "#9ca3af" : iconColor;
- const path =
- iconPath ||
- "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
-
- const baseSize = isTracking ? 32 : 24;
- const renderSize = baseSize * 2;
-
- let svg = "";
- if (isTracking) {
- svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${renderSize}" height="${renderSize}" viewBox="0 0 32 32">
- <circle cx="16" cy="16" r="10" fill="none" stroke="#3b82f6" stroke-width="2">
- <animate attributeName="r" from="10" to="15" dur="1.5s" repeatCount="indefinite" />
- <animate attributeName="stroke-opacity" from="1" to="0" dur="1.5s" repeatCount="indefinite" />
- </circle>
- <circle cx="16" cy="16" r="10" fill="#3b82f6" fill-opacity="0.2">
- <animate attributeName="r" from="8" to="12" dur="1.5s" repeatCount="indefinite" />
- </circle>
- <g transform="translate(4,4)">
- <path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/>
- </g>
- </svg>`;
- } else {
- svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${renderSize}" height="${renderSize}" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
- }
-
- const svgBytes = new TextEncoder().encode(svg);
- let svgBinary = "";
- const chunkSize = 8192;
- for (let i = 0; i < svgBytes.length; i += chunkSize) {
- const sub = svgBytes.subarray(i, i + chunkSize);
- svgBinary += String.fromCharCode(...sub);
- }
- const src = "data:image/svg+xml;base64," + btoa(svgBinary);
-
- const displayHeight = renderSize * scale;
- const labelOffset = -(displayHeight + 6);
-
- const style = new Style({
- image: new Icon({
- src: src,
- anchor: [0.5, 1],
- scale: scale,
- imgSize: [renderSize, renderSize],
- }),
- text: new Text({
- text: label,
- offsetY: labelOffset,
- font: "bold 12px sans-serif",
- fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
- stroke: new Stroke({ color: "#ffffff", width: 3 }),
- }),
+ createMarkerStyle({
+ iconColor,
+ bgColor,
+ label,
+ isStale,
+ iconPath,
+ scale = BADGE_SCALE_NORMAL,
+ isTracking = false,
+ showLabel = true,
+ }) {
+ return getCachedPeerBadgeStyle(this.styleCache, {
+ iconColor,
+ bgColor,
+ label,
+ isStale,
+ iconPath,
+ scale,
+ isTracking,
+ showLabel,
});
-
- this.styleCache[cacheKey] = style;
- return style;
},
onMarkerClick(feature) {
this.selectedMarker = {

diff --git a/meshchatx/src/frontend/components/map/internal/markerStyles.js b/meshchatx/src/frontend/components/map/internal/markerStyles.js
new file mode 100644
index 00000000..9d534a37
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/internal/markerStyles.js
@@ -0,0 +1,351 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Pure OpenLayers marker style builders for the discovery map.
+ *
+ * Badge SVGs use a dual halo (dark outer plus light inner rim) so the same asset
+ * stays readable on both dark and light basemaps without theme branching.
+ */
+
+import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
+
+/** Default indigo face for peers without an LXMF user icon. */
+export const DEFAULT_PEER_FACE = "#3730a3";
+
+/** Default white glyph on peer badges. */
+export const DEFAULT_PEER_GLYPH = "#ffffff";
+
+/** Emerald face for discovered interface markers. */
+export const DEFAULT_DISCOVERED_FACE = "#059669";
+
+/** Fallback MDI-style map pin path (24x24 viewBox). */
+export const DEFAULT_MAP_PIN_PATH =
+ "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
+
+/** Show permanent name labels at or above this zoom. */
+export const MARKER_LABEL_MIN_ZOOM = 12;
+
+/** Normal badge Icon scale. */
+export const BADGE_SCALE_NORMAL = 1;
+
+/** Hovered badge Icon scale. */
+export const BADGE_SCALE_HOVER = 1.12;
+
+const STALE_FACE = "#64748b";
+const STALE_GLYPH = "#e2e8f0";
+
+/**
+ * Parse #rgb / #rrggbb into 0-1 linear-ish sRGB channels for luminance.
+ * @param {string} color
+ * @returns {{r:number,g:number,b:number}|null}
+ */
+function parseHexColor(color) {
+ if (typeof color !== "string") return null;
+ let hex = color.trim();
+ if (hex.startsWith("#")) hex = hex.slice(1);
+ if (hex.length === 3) {
+ hex = hex
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ }
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
+ return {
+ r: parseInt(hex.slice(0, 2), 16) / 255,
+ g: parseInt(hex.slice(2, 4), 16) / 255,
+ b: parseInt(hex.slice(4, 6), 16) / 255,
+ };
+}
+
+/**
+ * Relative luminance (WCAG-ish) for a hex color.
+ * @param {string} color
+ * @returns {number} 0-1, or 0.5 when unparseable
+ */
+export function relativeLuminance(color) {
+ const rgb = parseHexColor(color);
+ if (!rgb) return 0.5;
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+ return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
+}
+
+/**
+ * Contrast ratio between two hex colors.
+ * @param {string} a
+ * @param {string} b
+ * @returns {number}
+ */
+export function contrastRatio(a, b) {
+ const L1 = relativeLuminance(a);
+ const L2 = relativeLuminance(b);
+ const lighter = Math.max(L1, L2);
+ const darker = Math.min(L1, L2);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/**
+ * Pick a readable glyph color for a badge face.
+ * Prefers the preferred color when contrast against bg is at least 3 to 1.
+ * @param {string} bg
+ * @param {string} [preferred]
+ * @returns {string}
+ */
+export function contrastGlyphColor(bg, preferred) {
+ const white = "#ffffff";
+ const ink = "#0f172a";
+ if (preferred && contrastRatio(preferred, bg) >= 3) {
+ return preferred;
+ }
+ return relativeLuminance(bg) > 0.45 ? ink : white;
+}
+
+/**
+ * Whether a marker name label should render.
+ * @param {{zoom?:number, hovered?:boolean, selected?:boolean}} opts
+ * @returns {boolean}
+ */
+export function shouldShowMarkerLabel({ zoom, hovered = false, selected = false } = {}) {
+ if (hovered || selected) return true;
+ return Number.isFinite(zoom) && zoom >= MARKER_LABEL_MIN_ZOOM;
+}
+
+/**
+ * Encode an SVG markup string as a data URL for ol/style/Icon.
+ * @param {string} svg
+ * @returns {string}
+ */
+export function encodeSvgDataUrl(svg) {
+ const bytes = new TextEncoder().encode(svg);
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return "data:image/svg+xml;base64," + btoa(binary);
+}
+
+/**
+ * Build dual-halo peer / discovered badge SVG markup.
+ * @param {{face:string, glyph:string, pathD?:string, stale?:boolean, tracking?:boolean}} opts
+ * @returns {string}
+ */
+export function buildPeerBadgeSvg({ face, glyph, pathD = DEFAULT_MAP_PIN_PATH, stale = false, tracking = false } = {}) {
+ const faceColor = stale ? STALE_FACE : face || DEFAULT_PEER_FACE;
+ const glyphColor = stale ? STALE_GLYPH : glyph || DEFAULT_PEER_GLYPH;
+ const opacity = stale ? "0.72" : "1";
+ const showPulse = tracking && !stale;
+ const size = showPulse ? 48 : 40;
+ const cx = size / 2;
+ const cy = size / 2;
+ const r = showPulse ? 12 : 11;
+
+ const pulse = showPulse
+ ? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#38bdf8" stroke-width="2">
+ <animate attributeName="r" from="${r}" to="${r + 8}" dur="1.5s" repeatCount="indefinite" />
+ <animate attributeName="stroke-opacity" from="0.9" to="0" dur="1.5s" repeatCount="indefinite" />
+ </circle>
+ <circle cx="${cx}" cy="${cy}" r="${r - 1}" fill="#38bdf8" fill-opacity="0.18">
+ <animate attributeName="r" from="${r - 2}" to="${r + 3}" dur="1.5s" repeatCount="indefinite" />
+ <animate attributeName="fill-opacity" from="0.22" to="0" dur="1.5s" repeatCount="indefinite" />
+ </circle>`
+ : "";
+
+ // Dual halo is dark outer rim plus light inner rim then face fill then glyph.
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <g opacity="${opacity}">
+ <circle class="badge-shadow" cx="${cx + 0.6}" cy="${cy + 1.1}" r="${r + 1}" fill="rgba(15,23,42,0.28)"/>
+ ${pulse}
+ <circle class="badge-rim-dark" cx="${cx}" cy="${cy}" r="${r}" fill="${faceColor}" stroke="rgba(15,23,42,0.78)" stroke-width="3"/>
+ <circle class="badge-rim-light" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="rgba(255,255,255,0.92)" stroke-width="1.6"/>
+ <g transform="translate(${cx},${cy}) scale(0.52) translate(-12,-12)">
+ <path d="${pathD}" fill="${glyphColor}"/>
+ </g>
+ </g>
+</svg>`;
+}
+
+/**
+ * Create an OpenLayers Style for a peer / discovered / note badge.
+ * @param {object} opts
+ * @param {string} [opts.face]
+ * @param {string} [opts.glyph]
+ * @param {string} [opts.iconColor] preferred glyph (legacy name from MapPage)
+ * @param {string} [opts.bgColor] face (legacy name from MapPage)
+ * @param {string} [opts.pathD]
+ * @param {string} [opts.iconPath] alias for pathD
+ * @param {string} [opts.label]
+ * @param {boolean} [opts.showLabel]
+ * @param {boolean} [opts.isStale]
+ * @param {boolean} [opts.isTracking]
+ * @param {number} [opts.scale]
+ * @returns {import("ol/style/Style").default}
+ */
+export function peerBadgeStyle({
+ face,
+ glyph,
+ iconColor,
+ bgColor,
+ pathD,
+ iconPath,
+ label = "",
+ showLabel = false,
+ isStale = false,
+ isTracking = false,
+ scale = BADGE_SCALE_NORMAL,
+} = {}) {
+ const resolvedFace = face || bgColor || DEFAULT_PEER_FACE;
+ const preferredGlyph = glyph || iconColor || DEFAULT_PEER_GLYPH;
+ const resolvedGlyph = isStale ? STALE_GLYPH : contrastGlyphColor(resolvedFace, preferredGlyph);
+ const d = pathD || iconPath || DEFAULT_MAP_PIN_PATH;
+
+ const svg = buildPeerBadgeSvg({
+ face: resolvedFace,
+ glyph: resolvedGlyph,
+ pathD: d,
+ stale: isStale,
+ tracking: isTracking,
+ });
+ const src = encodeSvgDataUrl(svg);
+ const renderSize = isTracking && !isStale ? 48 : 40;
+ const displayHeight = renderSize * scale;
+ const labelOffset = -(displayHeight / 2 + 8);
+
+ const styleOpts = {
+ image: new Icon({
+ src,
+ anchor: [0.5, 0.5],
+ scale,
+ imgSize: [renderSize, renderSize],
+ }),
+ };
+
+ if (showLabel && label) {
+ styleOpts.text = new Text({
+ text: String(label),
+ offsetY: labelOffset,
+ font: "bold 12px sans-serif",
+ fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
+ stroke: new Stroke({ color: "#ffffff", width: 3 }),
+ });
+ }
+
+ return new Style(styleOpts);
+}
+
+/**
+ * Cluster size / color band for a marker count.
+ * @param {number} count
+ * @returns {{bandId:string, radius:number, face:string}}
+ */
+export function clusterBand(count) {
+ const n = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
+ if (n < 10) {
+ return { bandId: "s", radius: 16, face: "#2563eb" };
+ }
+ if (n < 50) {
+ return { bandId: "m", radius: 20, face: "#7c3aed" };
+ }
+ return { bandId: "l", radius: 24, face: "#c2410c" };
+}
+
+/**
+ * Dual-halo cluster badge as layered OpenLayers styles (no SVG encode).
+ * @param {{count:number, hovered?:boolean}} opts
+ * @returns {import("ol/style/Style").default[]}
+ */
+export function clusterBadgeStyle({ count = 0, hovered = false } = {}) {
+ const band = clusterBand(count);
+ const radius = hovered ? band.radius + 2 : band.radius;
+ const label = String(count);
+
+ return [
+ new Style({
+ image: new CircleStyle({
+ radius: radius + 2.5,
+ fill: new Fill({ color: "rgba(15, 23, 42, 0.32)" }),
+ stroke: new Stroke({ color: "rgba(15, 23, 42, 0.55)", width: 2 }),
+ }),
+ zIndex: 0,
+ }),
+ new Style({
+ image: new CircleStyle({
+ radius,
+ fill: new Fill({ color: band.face }),
+ stroke: new Stroke({ color: "#ffffff", width: 2.5 }),
+ }),
+ text: new Text({
+ text: label,
+ font: "bold 13px sans-serif",
+ fill: new Fill({ color: "#ffffff" }),
+ stroke: new Stroke({ color: "rgba(15,23,42,0.45)", width: 2 }),
+ }),
+ zIndex: 1,
+ }),
+ ];
+}
+
+/**
+ * Cache-aware cluster style helper.
+ * @param {Record<string, import("ol/style/Style").default[]>} cache
+ * @param {{count:number, hovered?:boolean}} opts
+ * @returns {import("ol/style/Style").default[]}
+ */
+export function getCachedClusterStyle(cache, { count = 0, hovered = false } = {}) {
+ const band = clusterBand(count);
+ const key = `cluster-v2-${band.bandId}-${count}-${hovered ? "h" : "n"}`;
+ if (cache[key]) return cache[key];
+ const style = clusterBadgeStyle({ count, hovered });
+ cache[key] = style;
+ return style;
+}
+
+/**
+ * Cache-aware peer badge style helper.
+ * @param {Record<string, import("ol/style/Style").default>} cache
+ * @param {object} opts
+ * @returns {import("ol/style/Style").default}
+ */
+export function getCachedPeerBadgeStyle(cache, opts = {}) {
+ const {
+ face,
+ glyph,
+ iconColor,
+ bgColor,
+ pathD,
+ iconPath,
+ label = "",
+ showLabel = false,
+ isStale = false,
+ isTracking = false,
+ scale = BADGE_SCALE_NORMAL,
+ } = opts;
+ const resolvedFace = face || bgColor || DEFAULT_PEER_FACE;
+ const preferredGlyph = glyph || iconColor || DEFAULT_PEER_GLYPH;
+ const resolvedGlyph = isStale ? STALE_GLYPH : contrastGlyphColor(resolvedFace, preferredGlyph);
+ const d = pathD || iconPath || DEFAULT_MAP_PIN_PATH;
+ const labelKey = showLabel && label ? String(label) : "";
+ const key = [
+ "peer-v2",
+ resolvedFace,
+ resolvedGlyph,
+ d,
+ labelKey,
+ showLabel ? "1" : "0",
+ isStale ? "1" : "0",
+ isTracking ? "1" : "0",
+ scale,
+ ].join("|");
+ if (cache[key]) return cache[key];
+ const style = peerBadgeStyle({
+ face: resolvedFace,
+ glyph: resolvedGlyph,
+ pathD: d,
+ label,
+ showLabel,
+ isStale,
+ isTracking,
+ scale,
+ });
+ cache[key] = style;
+ return style;
+}

diff --git a/meshchatx/src/frontend/js/mapTileProviders.js b/meshchatx/src/frontend/js/mapTileProviders.js
index 15f60d9a..38261d1f 100644
--- a/meshchatx/src/frontend/js/mapTileProviders.js
+++ b/meshchatx/src/frontend/js/mapTileProviders.js
@@ -1,10 +1,12 @@
-/** Raster basemap providers tried in order when tiles fail to load. */
+/** Canonical default basemap (OpenStreetMap raster). */
+export const DEFAULT_TILE_SERVER_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+/** Raster basemap providers tried in order when tiles fail to load. */
export const RASTER_TILE_PROVIDER_ORDER = ["osm", "openfreemap"];
export const TILE_PROVIDER_URLS = {
+ osm: DEFAULT_TILE_SERVER_URL,
openfreemap: "https://tiles.openfreemap.org/styles/bright",
- osm: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"carto-dark": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
"carto-voyager": "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
"carto-light": "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index f48f8d34..564cfaeb 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1449,7 +1449,7 @@
"clear_cache": "Cache leeren",
"cache_cleared": "Kachel-Cache geleert",
"tile_server_url": "Kachel-Server-URL",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Verwenden Sie {z}, {x}, {y} für Zoom, Spalte, Zeile",
"tile_server_saved": "Kachel-Server-URL gespeichert",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index ee471098..9372bbf7 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1397,7 +1397,7 @@
"clear_cache": "Clear Cache",
"cache_cleared": "Tile cache cleared",
"tile_server_url": "Tile Server URL",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Use {z}, {x}, {y} for zoom, column, row",
"tile_server_saved": "Tile server URL saved",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index f5f1eafc..7955d388 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1397,7 +1397,7 @@
"clear_cache": "Caché transparente",
"cache_cleared": "Caché de azul claro",
"tile_server_url": "URL del servidor",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Utilice{z},{x},{y}para zoom, columna, fila",
"tile_server_saved": "URL del servidor del azul",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 9b179cc6..c79c78bb 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1397,7 +1397,7 @@
"clear_cache": "Tyhjää välimuisti",
"cache_cleared": "Laattavälimuisti tyhjätty",
"tile_server_url": "Laattapalvelimen URL",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Käytä {z}, {x}, {y} lähennykselle, sarakkeelle, riville",
"tile_server_saved": "Laattapalvelimen URL tallennettu",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 335f86d8..431f24fe 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1397,7 +1397,7 @@
"clear_cache": "Effacer le cache",
"cache_cleared": "cache de carreaux effacé",
"tile_server_url": "URL du serveur Tile",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Utiliser{z},{x},{y}pour zoom, colonne, ligne",
"tile_server_saved": "URL du serveur Tile enregistrée",
"tile_server_openstreetmap": "OuvrirStreetMap",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 9d2af588..32017474 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1449,7 +1449,7 @@
"clear_cache": "Svuota Cache",
"cache_cleared": "Cache tile svuotata",
"tile_server_url": "URL Server Tile",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Usa {z}, {x}, {y} per zoom, colonna, riga",
"tile_server_saved": "URL server tile salvato",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index e95660a6..59dc80c2 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1397,7 +1397,7 @@
"clear_cache": "Cache wissen",
"cache_cleared": "Tegel cache gewist",
"tile_server_url": "Tegelserver-URL",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "{z},{x},{y}gebruiken voor zoom, kolom, rij",
"tile_server_saved": "Tegelserver-URL opgeslagen",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 5052848c..28163689 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1449,7 +1449,7 @@
"clear_cache": "Очистить кэш",
"cache_cleared": "Кэш тайлов очищен",
"tile_server_url": "URL сервера тайлов",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Используйте {z}, {x}, {y} для масштаба, столбца, строки",
"tile_server_saved": "URL сервера тайлов сохранен",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 7c951152..c8fc9d66 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1397,7 +1397,7 @@
"clear_cache": "清除缓存",
"cache_cleared": "瓦片缓存已清除",
"tile_server_url": "瓦片服务器 URL",
- "tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
+ "tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "使用 {z}、{x}、{y} 表示缩放、列、行",
"tile_server_saved": "瓦片服务器 URL 已保存",
"tile_server_openstreetmap": "OpenStreetMap",

diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh
index 43c2629c..b183070c 100644
--- a/scripts/dev-local.sh
+++ b/scripts/dev-local.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Vite HMR (http://127.0.0.1:5173) + MeshChat backend (HTTPS on MESHCHAT_PORT).
+# Vite HMR (http://127.0.0.1:5173) plus MeshChat backend (HTTPS on MESHCHAT_PORT).
# Vite proxies /api and /ws to https://127.0.0.1:$MESHCHAT_PORT.
set -euo pipefail
@@ -51,7 +51,7 @@ while (( SECONDS < deadline )); do
if ss -ltn "( sport = :${MESHCHAT_PORT} )" 2>/dev/null | grep -q LISTEN; then
saw_listen=1
fi
- # Use /api/v1/status: it is exempt from auth and the "still starting" 503 gate
+ # Use /api/v1/status. It is exempt from auth and the still-starting 503 gate
# so the Vite process can start while deferred network finishes.
if curl -fsSk --max-time 2 "$BACKEND_URL" >/dev/null 2>&1; then
echo "[dev] Backend ready."

diff --git a/tests/frontend/mapMarkerStyles.test.js b/tests/frontend/mapMarkerStyles.test.js
new file mode 100644
index 00000000..4a8f12e6
--- /dev/null
+++ b/tests/frontend/mapMarkerStyles.test.js
@@ -0,0 +1,190 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import {
+ BADGE_SCALE_HOVER,
+ BADGE_SCALE_NORMAL,
+ DEFAULT_MAP_PIN_PATH,
+ DEFAULT_PEER_FACE,
+ MARKER_LABEL_MIN_ZOOM,
+ buildPeerBadgeSvg,
+ clusterBadgeStyle,
+ clusterBand,
+ contrastGlyphColor,
+ contrastRatio,
+ encodeSvgDataUrl,
+ getCachedClusterStyle,
+ getCachedPeerBadgeStyle,
+ peerBadgeStyle,
+ relativeLuminance,
+ shouldShowMarkerLabel,
+} from "@/components/map/internal/markerStyles.js";
+
+describe("relativeLuminance / contrastGlyphColor", () => {
+ it("rates white brighter than black", () => {
+ expect(relativeLuminance("#ffffff")).toBeGreaterThan(relativeLuminance("#000000"));
+ });
+
+ it("keeps a preferred glyph when contrast is strong", () => {
+ expect(contrastGlyphColor("#3730a3", "#ffffff")).toBe("#ffffff");
+ expect(contrastRatio("#ffffff", "#3730a3")).toBeGreaterThanOrEqual(3);
+ });
+
+ it("forces a readable glyph when preferred contrast is weak", () => {
+ // Light yellow on light yellow face would fail contrast.
+ const glyph = contrastGlyphColor("#fef9c3", "#fef08a");
+ expect(contrastRatio(glyph, "#fef9c3")).toBeGreaterThanOrEqual(3);
+ expect(glyph).toBe("#0f172a");
+ });
+
+ it("picks white on dark faces without a preferred color", () => {
+ expect(contrastGlyphColor("#0f172a")).toBe("#ffffff");
+ });
+});
+
+describe("buildPeerBadgeSvg", () => {
+ it("includes dual halo rims and a shadow", () => {
+ const svg = buildPeerBadgeSvg({
+ face: DEFAULT_PEER_FACE,
+ glyph: "#ffffff",
+ pathD: DEFAULT_MAP_PIN_PATH,
+ });
+ expect(svg).toContain('class="badge-rim-dark"');
+ expect(svg).toContain('class="badge-rim-light"');
+ expect(svg).toContain('class="badge-shadow"');
+ expect(svg).toContain(DEFAULT_MAP_PIN_PATH);
+ expect(svg).not.toContain("<animate");
+ });
+
+ it("mutes stale badges and skips pulse", () => {
+ const svg = buildPeerBadgeSvg({
+ face: "#ff0000",
+ glyph: "#00ff00",
+ stale: true,
+ tracking: true,
+ });
+ expect(svg).toContain('opacity="0.72"');
+ expect(svg).toContain("#64748b");
+ expect(svg).toContain("#e2e8f0");
+ expect(svg).not.toContain("<animate");
+ });
+
+ it("adds pulse animation only when tracking and not stale", () => {
+ const svg = buildPeerBadgeSvg({
+ face: DEFAULT_PEER_FACE,
+ glyph: "#ffffff",
+ tracking: true,
+ stale: false,
+ });
+ expect(svg).toContain("<animate");
+ expect(svg).toContain('stroke="#38bdf8"');
+ });
+});
+
+describe("encodeSvgDataUrl", () => {
+ it("round-trips a tiny SVG through base64", () => {
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="1"/></svg>';
+ const url = encodeSvgDataUrl(svg);
+ expect(url.startsWith("data:image/svg+xml;base64,")).toBe(true);
+ const b64 = url.slice("data:image/svg+xml;base64,".length);
+ expect(atob(b64)).toBe(svg);
+ });
+});
+
+describe("clusterBand", () => {
+ it("returns size bands with monotonic radius", () => {
+ const small = clusterBand(2);
+ const mid = clusterBand(10);
+ const large = clusterBand(50);
+ expect(small.bandId).toBe("s");
+ expect(mid.bandId).toBe("m");
+ expect(large.bandId).toBe("l");
+ expect(small.radius).toBeLessThan(mid.radius);
+ expect(mid.radius).toBeLessThan(large.radius);
+ expect(small.face).not.toBe(large.face);
+ });
+
+ it("treats boundaries correctly", () => {
+ expect(clusterBand(9).bandId).toBe("s");
+ expect(clusterBand(10).bandId).toBe("m");
+ expect(clusterBand(49).bandId).toBe("m");
+ expect(clusterBand(50).bandId).toBe("l");
+ });
+});
+
+describe("clusterBadgeStyle / getCachedClusterStyle", () => {
+ it("returns layered styles with count text", () => {
+ const styles = clusterBadgeStyle({ count: 12, hovered: false });
+ expect(Array.isArray(styles)).toBe(true);
+ expect(styles).toHaveLength(2);
+ expect(styles[1].getText().getText()).toBe("12");
+ });
+
+ it("returns the same cached object for identical inputs", () => {
+ const cache = {};
+ const a = getCachedClusterStyle(cache, { count: 7, hovered: false });
+ const b = getCachedClusterStyle(cache, { count: 7, hovered: false });
+ const c = getCachedClusterStyle(cache, { count: 7, hovered: true });
+ expect(a).toBe(b);
+ expect(a).not.toBe(c);
+ });
+});
+
+describe("peerBadgeStyle / getCachedPeerBadgeStyle", () => {
+ it("builds a center-anchored Icon style", () => {
+ const style = peerBadgeStyle({
+ face: DEFAULT_PEER_FACE,
+ glyph: "#ffffff",
+ label: "Alice",
+ showLabel: true,
+ scale: BADGE_SCALE_NORMAL,
+ });
+ const image = style.getImage();
+ expect(image.getScale()).toBe(BADGE_SCALE_NORMAL);
+ expect(image.getSrc().startsWith("data:image/svg+xml;base64,")).toBe(true);
+ expect(image.anchorXUnits_).toBe("fraction");
+ expect(image.anchorYUnits_).toBe("fraction");
+ expect(image.anchor_).toEqual([0.5, 0.5]);
+ expect(style.getText().getText()).toBe("Alice");
+ });
+
+ it("omits text when showLabel is false", () => {
+ const style = peerBadgeStyle({
+ face: DEFAULT_PEER_FACE,
+ label: "Hidden",
+ showLabel: false,
+ });
+ expect(style.getText()).toBeNull();
+ });
+
+ it("caches peer styles by discrete key", () => {
+ const cache = {};
+ const a = getCachedPeerBadgeStyle(cache, {
+ bgColor: DEFAULT_PEER_FACE,
+ iconColor: "#ffffff",
+ scale: BADGE_SCALE_HOVER,
+ showLabel: false,
+ });
+ const b = getCachedPeerBadgeStyle(cache, {
+ bgColor: DEFAULT_PEER_FACE,
+ iconColor: "#ffffff",
+ scale: BADGE_SCALE_HOVER,
+ showLabel: false,
+ });
+ expect(a).toBe(b);
+ });
+});
+
+describe("shouldShowMarkerLabel", () => {
+ it("shows labels at or above the min zoom", () => {
+ expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM })).toBe(true);
+ expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM + 1 })).toBe(true);
+ expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM - 1 })).toBe(false);
+ });
+
+ it("shows labels when hovered or selected even at low zoom", () => {
+ expect(shouldShowMarkerLabel({ zoom: 3, hovered: true })).toBe(true);
+ expect(shouldShowMarkerLabel({ zoom: 3, selected: true })).toBe(true);
+ expect(shouldShowMarkerLabel({ zoom: 3 })).toBe(false);
+ });
+});

diff --git a/tests/frontend/mapTileProviders.test.js b/tests/frontend/mapTileProviders.test.js
index bd123e98..d325c37c 100644
--- a/tests/frontend/mapTileProviders.test.js
+++ b/tests/frontend/mapTileProviders.test.js
@@ -1,11 +1,19 @@
import { describe, expect, it } from "vitest";
import {
+ DEFAULT_TILE_SERVER_URL,
detectRasterTileProviderId,
nextRasterTileProviderId,
RASTER_TILE_PROVIDER_ORDER,
+ TILE_PROVIDER_URLS,
} from "../../meshchatx/src/frontend/js/mapTileProviders.js";
describe("mapTileProviders", () => {
+ it("defaults to OSM raster URL", () => {
+ expect(DEFAULT_TILE_SERVER_URL).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
+ expect(TILE_PROVIDER_URLS.osm).toBe(DEFAULT_TILE_SERVER_URL);
+ expect(RASTER_TILE_PROVIDER_ORDER[0]).toBe("osm");
+ });
+
it("detects provider from URL", () => {
expect(detectRasterTileProviderId("https://tiles.openfreemap.org/styles/bright")).toBe("openfreemap");
expect(detectRasterTileProviderId("https://tile.openstreetmap.org/1/1/1.png")).toBe("osm");


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────